import { eltP } from '../util/dom.js';
import { eventMixin, hasHandler, on } from '../util/event.js';
import { endOperation, operation, runInOp, startOperation } from '../display/operations.js';
import { clipPos, cmp, Pos } from '../line/pos.js';
import { lineNo, updateLineHeight } from '../line/utils_line.js';
import { clearLineMeasurementCacheFor, findViewForLine, textHeight } from '../measurement/position_measurement.js';
import { seeReadOnlySpans, seeCollapsedSpans } from '../line/saw_special_spans.js';
import {
  addMarkedSpan,
  conflictingCollapsedRange,
  getMarkedSpanFor,
  lineIsHidden,
  lineLength,
  MarkedSpan,
  removeMarkedSpan,
  visualLine,
} from '../line/spans.js';
import { copyObj, indexOf, lst } from '../util/misc.js';
import { signalLater } from '../util/operation_group.js';
import { widgetHeight } from '../measurement/widgets.js';
import { regChange, regLineChange } from '../display/view_tracking.js';

import { linkedDocs } from './document_data.js';
import { addChangeToHistory } from './history.js';
import { reCheckSelection } from './selection_updates.js';

// TEXTMARKERS

// Created with markText and setBookmark methods. A TextMarker is a
// handle that can be used to clear or find a marked position in the
// document. Line objects hold arrays (markedSpans) containing
// {from, to, marker} object pointing to such marker objects, and
// indicating that such a marker is present on that line. Multiple
// lines may point to the same marker when it spans across lines.
// The spans will have null for their from/to properties when the
// marker continues beyond the start/end of the line. Markers have
// links back to the lines they currently touch.

// Collapsed markers have unique ids, in order to be able to order
// them, which is needed for uniquely determining an outer marker
// when they overlap (they may nest, but not partially overlap).
let nextMarkerId = 0;

export class TextMarker {
  constructor(doc, type) {
    this.lines = [];
    this.type = type;
    this.doc = doc;
    this.id = ++nextMarkerId;
  }

  // Clear the marker.
  clear() {
    if (this.explicitlyCleared) return;
    let cm = this.doc.cm,
      withOp = cm && !cm.curOp;
    if (withOp) startOperation(cm);
    if (hasHandler(this, 'clear')) {
      let found = this.find();
      if (found) signalLater(this, 'clear', found.from, found.to);
    }
    let min = null,
      max = null;
    for (let i = 0; i < this.lines.length; ++i) {
      let line = this.lines[i];
      let span = getMarkedSpanFor(line.markedSpans, this);
      if (cm && !this.collapsed) regLineChange(cm, lineNo(line), 'text');
      else if (cm) {
        if (span.to != null) max = lineNo(line);
        if (span.from != null) min = lineNo(line);
      }
      line.markedSpans = removeMarkedSpan(line.markedSpans, span);
      if (span.from == null && this.collapsed && !lineIsHidden(this.doc, line) && cm)
        updateLineHeight(line, textHeight(cm.display));
    }
    if (cm && this.collapsed && !cm.options.lineWrapping)
      for (let i = 0; i < this.lines.length; ++i) {
        let visual = visualLine(this.lines[i]),
          len = lineLength(visual);
        if (len > cm.display.maxLineLength) {
          cm.display.maxLine = visual;
          cm.display.maxLineLength = len;
          cm.display.maxLineChanged = true;
        }
      }

    if (min != null && cm && this.collapsed) regChange(cm, min, max + 1);
    this.lines.length = 0;
    this.explicitlyCleared = true;
    if (this.atomic && this.doc.cantEdit) {
      this.doc.cantEdit = false;
      if (cm) reCheckSelection(cm.doc);
    }
    if (cm) signalLater(cm, 'markerCleared', cm, this, min, max);
    if (withOp) endOperation(cm);
    if (this.parent) this.parent.clear();
  }

  // Find the position of the marker in the document. Returns a {from,
  // to} object by default. Side can be passed to get a specific side
  // -- 0 (both), -1 (left), or 1 (right). When lineObj is true, the
  // Pos objects returned contain a line object, rather than a line
  // number (used to prevent looking up the same line twice).
  find(side, lineObj) {
    if (side == null && this.type == 'bookmark') side = 1;
    let from, to;
    for (let i = 0; i < this.lines.length; ++i) {
      let line = this.lines[i];
      let span = getMarkedSpanFor(line.markedSpans, this);
      if (span.from != null) {
        from = Pos(lineObj ? line : lineNo(line), span.from);
        if (side == -1) return from;
      }
      if (span.to != null) {
        to = Pos(lineObj ? line : lineNo(line), span.to);
        if (side == 1) return to;
      }
    }
    return from && { from: from, to: to };
  }

  // Signals that the marker's widget changed, and surrounding layout
  // should be recomputed.
  changed() {
    let pos = this.find(-1, true),
      widget = this,
      cm = this.doc.cm;
    if (!pos || !cm) return;
    runInOp(cm, () => {
      let line = pos.line,
        lineN = lineNo(pos.line);
      let view = findViewForLine(cm, lineN);
      if (view) {
        clearLineMeasurementCacheFor(view);
        cm.curOp.selectionChanged = cm.curOp.forceUpdate = true;
      }
      cm.curOp.updateMaxLine = true;
      if (!lineIsHidden(widget.doc, line) && widget.height != null) {
        let oldHeight = widget.height;
        widget.height = null;
        let dHeight = widgetHeight(widget) - oldHeight;
        if (dHeight) updateLineHeight(line, line.height + dHeight);
      }
      signalLater(cm, 'markerChanged', cm, this);
    });
  }

  attachLine(line) {
    if (!this.lines.length && this.doc.cm) {
      let op = this.doc.cm.curOp;
      if (!op.maybeHiddenMarkers || indexOf(op.maybeHiddenMarkers, this) == -1)
        (op.maybeUnhiddenMarkers || (op.maybeUnhiddenMarkers = [])).push(this);
    }
    this.lines.push(line);
  }

  detachLine(line) {
    this.lines.splice(indexOf(this.lines, line), 1);
    if (!this.lines.length && this.doc.cm) {
      let op = this.doc.cm.curOp;
      (op.maybeHiddenMarkers || (op.maybeHiddenMarkers = [])).push(this);
    }
  }
}
eventMixin(TextMarker);

// Create a marker, wire it up to the right lines, and
export function markText(doc, from, to, options, type) {
  // Shared markers (across linked documents) are handled separately
  // (markTextShared will call out to this again, once per
  // document).
  if (options && options.shared) return markTextShared(doc, from, to, options, type);
  // Ensure we are in an operation.
  if (doc.cm && !doc.cm.curOp) return operation(doc.cm, markText)(doc, from, to, options, type);

  let marker = new TextMarker(doc, type),
    diff = cmp(from, to);
  if (options) copyObj(options, marker, false);
  // Don't connect empty markers unless clearWhenEmpty is false
  if (diff > 0 || (diff == 0 && marker.clearWhenEmpty !== false)) return marker;
  if (marker.replacedWith) {
    // Showing up as a widget implies collapsed (widget replaces text)
    marker.collapsed = true;
    marker.widgetNode = eltP('span', [marker.replacedWith], 'CodeMirror-widget');
    if (!options.handleMouseEvents) marker.widgetNode.setAttribute('cm-ignore-events', 'true');
    if (options.insertLeft) marker.widgetNode.insertLeft = true;
  }
  if (marker.collapsed) {
    if (
      conflictingCollapsedRange(doc, from.line, from, to, marker) ||
      (from.line != to.line && conflictingCollapsedRange(doc, to.line, from, to, marker))
    )
      throw new Error('Inserting collapsed marker partially overlapping an existing one');
    seeCollapsedSpans();
  }

  if (marker.addToHistory) addChangeToHistory(doc, { from: from, to: to, origin: 'markText' }, doc.sel, NaN);

  let curLine = from.line,
    cm = doc.cm,
    updateMaxLine;
  doc.iter(curLine, to.line + 1, line => {
    if (cm && marker.collapsed && !cm.options.lineWrapping && visualLine(line) == cm.display.maxLine)
      updateMaxLine = true;
    if (marker.collapsed && curLine != from.line) updateLineHeight(line, 0);
    addMarkedSpan(
      line,
      new MarkedSpan(marker, curLine == from.line ? from.ch : null, curLine == to.line ? to.ch : null),
      doc.cm && doc.cm.curOp
    );
    ++curLine;
  });
  // lineIsHidden depends on the presence of the spans, so needs a second pass
  if (marker.collapsed)
    doc.iter(from.line, to.line + 1, line => {
      if (lineIsHidden(doc, line)) updateLineHeight(line, 0);
    });

  if (marker.clearOnEnter) on(marker, 'beforeCursorEnter', () => marker.clear());

  if (marker.readOnly) {
    seeReadOnlySpans();
    if (doc.history.done.length || doc.history.undone.length) doc.clearHistory();
  }
  if (marker.collapsed) {
    marker.id = ++nextMarkerId;
    marker.atomic = true;
  }
  if (cm) {
    // Sync editor state
    if (updateMaxLine) cm.curOp.updateMaxLine = true;
    if (marker.collapsed) regChange(cm, from.line, to.line + 1);
    else if (
      marker.className ||
      marker.startStyle ||
      marker.endStyle ||
      marker.css ||
      marker.attributes ||
      marker.title
    )
      for (let i = from.line; i <= to.line; i++) regLineChange(cm, i, 'text');
    if (marker.atomic) reCheckSelection(cm.doc);
    signalLater(cm, 'markerAdded', cm, marker);
  }
  return marker;
}

// SHARED TEXTMARKERS

// A shared marker spans multiple linked documents. It is
// implemented as a meta-marker-object controlling multiple normal
// markers.
export class SharedTextMarker {
  constructor(markers, primary) {
    this.markers = markers;
    this.primary = primary;
    for (let i = 0; i < markers.length; ++i) markers[i].parent = this;
  }

  clear() {
    if (this.explicitlyCleared) return;
    this.explicitlyCleared = true;
    for (let i = 0; i < this.markers.length; ++i) this.markers[i].clear();
    signalLater(this, 'clear');
  }

  find(side, lineObj) {
    return this.primary.find(side, lineObj);
  }
}
eventMixin(SharedTextMarker);

function markTextShared(doc, from, to, options, type) {
  options = copyObj(options);
  options.shared = false;
  let markers = [markText(doc, from, to, options, type)],
    primary = markers[0];
  let widget = options.widgetNode;
  linkedDocs(doc, doc => {
    if (widget) options.widgetNode = widget.cloneNode(true);
    markers.push(markText(doc, clipPos(doc, from), clipPos(doc, to), options, type));
    for (let i = 0; i < doc.linked.length; ++i) if (doc.linked[i].isParent) return;
    primary = lst(markers);
  });
  return new SharedTextMarker(markers, primary);
}

export function findSharedMarkers(doc) {
  return doc.findMarks(Pos(doc.first, 0), doc.clipPos(Pos(doc.lastLine())), m => m.parent);
}

export function copySharedMarkers(doc, markers) {
  for (let i = 0; i < markers.length; i++) {
    let marker = markers[i],
      pos = marker.find();
    let mFrom = doc.clipPos(pos.from),
      mTo = doc.clipPos(pos.to);
    if (cmp(mFrom, mTo)) {
      let subMark = markText(doc, mFrom, mTo, marker.primary, marker.primary.type);
      marker.markers.push(subMark);
      subMark.parent = marker;
    }
  }
}

export function detachSharedMarkers(markers) {
  for (let i = 0; i < markers.length; i++) {
    let marker = markers[i],
      linked = [marker.primary.doc];
    linkedDocs(marker.primary.doc, d => linked.push(d));
    for (let j = 0; j < marker.markers.length; j++) {
      let subMarker = marker.markers[j];
      if (indexOf(linked, subMarker.doc) == -1) {
        subMarker.parent = null;
        marker.markers.splice(j--, 1);
      }
    }
  }
}
